Compare commits
9
Commits
7432a9af05
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfe5bfb788 | ||
|
|
55f172de3b | ||
|
|
f9ce4b218e | ||
|
|
03440d269d | ||
|
|
0a0ac00813 | ||
|
|
54cb335d66 | ||
|
|
236a08c865 | ||
|
|
d4202de0e1 | ||
|
|
7de6c3075e |
+9
-3
@@ -1,8 +1,14 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=4000
|
PORT=4000
|
||||||
PUBLIC_BASE_URL=http://localhost:4000
|
# Public URLs (production defaults for Coolify).
|
||||||
WEB_ORIGIN=http://localhost:3000
|
# Local development: use http://localhost:4000 / http://localhost:3001 /
|
||||||
|
# http://localhost:4000 instead.
|
||||||
|
PUBLIC_BASE_URL=https://api.altdock.app.amania.jp
|
||||||
|
WEB_ORIGIN=https://altdock.app.amania.jp
|
||||||
NEXT_PUBLIC_AUTH_MODE=demo
|
NEXT_PUBLIC_AUTH_MODE=demo
|
||||||
|
# Vite dev server Host allow-list (comma-separated, or * for any host).
|
||||||
|
# On Coolify set this to your public domain, e.g. altdock.app.amania.jp
|
||||||
|
VITE_ALLOWED_HOSTS=altdock.app.amania.jp
|
||||||
|
|
||||||
# --- Storage (MinIO via docker compose) -------------------------------------
|
# --- Storage (MinIO via docker compose) -------------------------------------
|
||||||
# Leave DATABASE_URL empty to run with an in-memory store instead of Postgres.
|
# Leave DATABASE_URL empty to run with an in-memory store instead of Postgres.
|
||||||
@@ -31,7 +37,7 @@ SESSION_SECRET=replace-with-at-least-32-random-characters
|
|||||||
OIDC_ISSUER_URL=
|
OIDC_ISSUER_URL=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
OIDC_REDIRECT_URI=http://localhost:4000/auth/callback
|
OIDC_REDIRECT_URI=https://api.altdock.app.amania.jp/auth/callback
|
||||||
|
|
||||||
MAX_UPLOAD_BYTES=5368709120
|
MAX_UPLOAD_BYTES=5368709120
|
||||||
MAX_ARCHIVE_ENTRIES=2048
|
MAX_ARCHIVE_ENTRIES=2048
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ FROM node:22-bookworm
|
|||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Build-time commit SHA, injected by docker compose (SOURCE_COMMIT) or Coolify.
|
||||||
|
# Exposed to the API at /version for deploy diagnostics.
|
||||||
|
ARG APP_COMMIT=unknown
|
||||||
|
ENV APP_COMMIT=${APP_COMMIT}
|
||||||
|
|
||||||
# package-lock.json is committed, so npm ci installs the exact dependency tree.
|
# package-lock.json is committed, so npm ci installs the exact dependency tree.
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,86 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
import { ArrowUpRight, KeyRound, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
import { useEffect, useState } from "react";
|
||||||
return <><div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1>設定</h1><p>認証と配布上限は環境設定で管理します。</p></div></div><section className="admin-section-card settings-placeholder"><div className="settings-placeholder-icon"><SlidersHorizontal size={20} /></div><h2>設定画面は準備中です</h2><p>SSO、ストレージ、アップロード上限は現在の環境変数と管理者設定で制御されています。</p><div className="settings-placeholder-grid"><div><ShieldCheck size={17} /><strong>認証</strong><span>OIDC / demo mode</span></div><div><KeyRound size={17} /><strong>配布</strong><span>Workspace単位の認可</span></div></div><Link className="admin-link" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank">PALドキュメントを開く <ArrowUpRight size={14} /></Link></section></>;
|
import { BookOpen, Database, HardDrive, KeyRound, LogOut, Server, ShieldCheck, SlidersHorizontal, User, Workflow } from "lucide-react";
|
||||||
|
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
|
||||||
|
import { useDashboard } from "@/components/altdock/dashboard-provider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Identity } from "@/packages/core/src/index";
|
||||||
|
|
||||||
|
function SettingRow({ icon: Icon, label, value, hint }: { icon: typeof User; label: string; value: React.ReactNode; hint?: string }) {
|
||||||
|
return <div className="settings-row"><span className="settings-row-icon"><Icon size={16} strokeWidth={1.8} /></span><div className="settings-row-copy"><span>{label}</span><strong>{value}</strong>{hint && <small>{hint}</small>}</div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||||
|
return <section className="admin-section-card settings-section"><div className="admin-section-card-header"><div><h2>{title}</h2><p>{description}</p></div></div>{children}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { dashboard } = useDashboard();
|
||||||
|
const [identity, setIdentity] = useState<Identity | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (AUTH_MODE !== "oidc") return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((body) => { if (!cancelled && body?.identity) setIdentity(body.identity as Identity); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1>設定</h1><p>Workspace・認証・配布上限の状態を確認できます。</p></div></div>
|
||||||
|
|
||||||
|
<div className="settings-grid">
|
||||||
|
<Section title="Workspace" description="AltDock上の配布単位となるWorkspace情報です。">
|
||||||
|
<div className="settings-rows">
|
||||||
|
<SettingRow icon={Workflow} label="Workspace名" value={dashboard?.workspace.name || "—"} />
|
||||||
|
<SettingRow icon={SlidersHorizontal} label="Workspace slug" value={<code>{dashboard?.workspace.slug || "—"}</code>} />
|
||||||
|
<SettingRow icon={User} label="ログインユーザー" value={AUTH_MODE === "oidc" ? (identity?.displayName || "—") : "Demo Developer"} hint={AUTH_MODE === "oidc" ? identity?.email : "demo@altdock.local"} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="認証" description="このAPIに接続している認証モードです。">
|
||||||
|
<div className="settings-rows">
|
||||||
|
<SettingRow icon={ShieldCheck} label="認証モード" value={AUTH_MODE === "oidc" ? <span className="settings-badge settings-badge-ok">OIDC SSO</span> : <span className="settings-badge">Demo mode</span>} hint={AUTH_MODE === "oidc" ? "汎用OIDCプロバイダでログインしています" : "開発用の簡易認証です。本番ではOIDCを推奨します"} />
|
||||||
|
{AUTH_MODE === "oidc" && <SettingRow icon={KeyRound} label="SSOセッション" value={identity ? <span className="settings-badge settings-badge-ok">ログイン中</span> : <span className="settings-badge">セッション確認中…</span>} />}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="保存容量" description="Workspaceで使用しているストレージの割合です。">
|
||||||
|
<div className="settings-quota">
|
||||||
|
<div className="settings-quota-top"><span>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</span><strong>{storagePercent.toFixed(1)}%</strong></div>
|
||||||
|
<div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div>
|
||||||
|
<small>ADP ZIP本体と公開済みアセットの合計サイズ</small>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="配布上限" description="環境変数で変更できる制限値です。">
|
||||||
|
<div className="settings-rows">
|
||||||
|
<SettingRow icon={HardDrive} label="ADP ZIP 1件の上限" value={formatBytes(dashboard?.limits.maxUploadBytes || 0)} />
|
||||||
|
<SettingRow icon={Database} label="WorkspaceあたりのSource数" value={dashboard?.limits.maxSources || "—"} />
|
||||||
|
<SettingRow icon={Workflow} label="Sourceあたりのアプリ数" value={dashboard?.limits.maxAppsPerSource || "—"} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="API" description="フロントエンドが利用しているAPIエンドポイントです。">
|
||||||
|
<div className="settings-rows">
|
||||||
|
<SettingRow icon={Server} label="API Base URL" value={<code>{API_BASE}</code>} />
|
||||||
|
<SettingRow icon={Server} label="ヘルスチェック" value={<a className="admin-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">/healthz を開く</a>} />
|
||||||
|
<SettingRow icon={Server} label="バージョン" value={<a className="admin-link" href={`${API_BASE}/version`} target="_blank" rel="noreferrer">/version を開く</a>} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="admin-section-card settings-footer">
|
||||||
|
<div><ShieldCheck size={17} /><span><strong>AltDock 管理画面</strong><small>Source・アプリ・リリースは左のナビゲーションから操作できます。</small></span></div>
|
||||||
|
<div className="settings-footer-actions">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => window.open("https://faq.altstore.io/developers/distribute-with-altstore-pal", "_blank")}><BookOpen size={14} />PALドキュメント</Button>
|
||||||
|
{AUTH_MODE === "oidc" && <Button variant="ghost" size="sm" onClick={() => { window.location.href = `${API_BASE}/auth/logout`; }}><LogOut size={14} />ログアウト</Button>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ button { cursor: pointer; }
|
|||||||
.admin-toast-viewport { position: fixed; z-index: 100; right: 18px; bottom: 18px; display: flex; width: 360px; max-width: calc(100vw - 36px); flex-direction: column; gap: 8px; outline: 0; }.admin-toast { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; padding: 13px 14px; border: 1px solid var(--admin-line-strong); border-left: 3px solid var(--admin-green); border-radius: 7px; color: var(--admin-text); background: #fff; box-shadow: 0 10px 32px #17202c22; }.admin-toast.toast-error { border-left-color: var(--admin-red); }.admin-toast.toast-info { border-left-color: var(--admin-blue); }.admin-toast [data-radix-toast-title] { font-size: 11px; font-weight: 720; }.admin-toast [data-radix-toast-description] { color: var(--admin-muted); font-size: 10px; line-height: 1.45; }.admin-toast button { grid-column: 2; grid-row: 1 / span 2; align-self: center; border: 0; color: var(--admin-subtle); background: transparent; font-size: 16px; }
|
.admin-toast-viewport { position: fixed; z-index: 100; right: 18px; bottom: 18px; display: flex; width: 360px; max-width: calc(100vw - 36px); flex-direction: column; gap: 8px; outline: 0; }.admin-toast { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; padding: 13px 14px; border: 1px solid var(--admin-line-strong); border-left: 3px solid var(--admin-green); border-radius: 7px; color: var(--admin-text); background: #fff; box-shadow: 0 10px 32px #17202c22; }.admin-toast.toast-error { border-left-color: var(--admin-red); }.admin-toast.toast-info { border-left-color: var(--admin-blue); }.admin-toast [data-radix-toast-title] { font-size: 11px; font-weight: 720; }.admin-toast [data-radix-toast-description] { color: var(--admin-muted); font-size: 10px; line-height: 1.45; }.admin-toast button { grid-column: 2; grid-row: 1 / span 2; align-self: center; border: 0; color: var(--admin-subtle); background: transparent; font-size: 16px; }
|
||||||
.mobile-nav-dialog { top: 0; left: 0; width: min(290px, calc(100vw - 44px)); height: 100vh; max-height: none; padding: 0; border: 0; border-radius: 0 9px 9px 0; transform: none; }.mobile-nav-dialog .admin-sidebar-inner { padding: 16px 12px; }.mobile-nav-close { position: absolute; z-index: 2; top: 16px; right: 13px; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border: 0; border-radius: 5px; color: var(--admin-subtle); background: #f1f3f5; }
|
.mobile-nav-dialog { top: 0; left: 0; width: min(290px, calc(100vw - 44px)); height: 100vh; max-height: none; padding: 0; border: 0; border-radius: 0 9px 9px 0; transform: none; }.mobile-nav-dialog .admin-sidebar-inner { padding: 16px 12px; }.mobile-nav-close { position: absolute; z-index: 2; top: 16px; right: 13px; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border: 0; border-radius: 5px; color: var(--admin-subtle); background: #f1f3f5; }
|
||||||
.settings-placeholder { max-width: 620px; padding: 34px; text-align: center; }.settings-placeholder-icon { display: inline-flex; width: 42px; height: 42px; align-items: center; justify-content: center; border-radius: 8px; color: var(--admin-orange); background: #fff1ed; }.settings-placeholder h2 { margin: 14px 0 7px; color: var(--admin-text); font-size: 16px; }.settings-placeholder > p { max-width: 420px; margin: 0 auto 22px; color: var(--admin-muted); font-size: 11px; line-height: 1.6; }.settings-placeholder-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 20px; }.settings-placeholder-grid > div { display: flex; align-items: center; gap: 8px; padding: 12px; border: 1px solid var(--admin-line); border-radius: 6px; text-align: left; }.settings-placeholder-grid svg { color: var(--admin-blue); }.settings-placeholder-grid strong, .settings-placeholder-grid span { display: block; }.settings-placeholder-grid strong { color: var(--admin-text); font-size: 10px; }.settings-placeholder-grid span { margin-top: 3px; color: var(--admin-subtle); font-size: 9px; }
|
.settings-placeholder { max-width: 620px; padding: 34px; text-align: center; }.settings-placeholder-icon { display: inline-flex; width: 42px; height: 42px; align-items: center; justify-content: center; border-radius: 8px; color: var(--admin-orange); background: #fff1ed; }.settings-placeholder h2 { margin: 14px 0 7px; color: var(--admin-text); font-size: 16px; }.settings-placeholder > p { max-width: 420px; margin: 0 auto 22px; color: var(--admin-muted); font-size: 11px; line-height: 1.6; }.settings-placeholder-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 20px; }.settings-placeholder-grid > div { display: flex; align-items: center; gap: 8px; padding: 12px; border: 1px solid var(--admin-line); border-radius: 6px; text-align: left; }.settings-placeholder-grid svg { color: var(--admin-blue); }.settings-placeholder-grid strong, .settings-placeholder-grid span { display: block; }.settings-placeholder-grid strong { color: var(--admin-text); font-size: 10px; }.settings-placeholder-grid span { margin-top: 3px; color: var(--admin-subtle); font-size: 9px; }
|
||||||
|
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }.settings-section { margin-bottom: 0; }.settings-section .admin-section-card-header { padding-bottom: 11px; }.settings-rows { display: flex; flex-direction: column; padding: 0 19px 17px; }.settings-row { display: flex; align-items: center; gap: 11px; min-height: 44px; padding: 9px 0; border-top: 1px solid var(--admin-line); }.settings-row:first-child { border-top: 0; }.settings-row-icon { display: inline-flex; width: 28px; height: 28px; flex: 0 0 28px; align-items: center; justify-content: center; border-radius: 6px; color: var(--admin-muted); background: #f2f4f6; }.settings-row-icon svg { color: var(--admin-muted); }.settings-row-copy { min-width: 0; flex: 1; }.settings-row-copy span { display: block; color: var(--admin-subtle); font-size: 9px; font-weight: 700; letter-spacing: .05em; }.settings-row-copy strong { display: block; overflow: hidden; margin-top: 2px; color: var(--admin-text); font-size: 11px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }.settings-row-copy code { color: var(--admin-muted); font-family: var(--font-geist-mono), monospace; font-size: 10px; }.settings-row-copy small { display: block; margin-top: 2px; color: var(--admin-subtle); font-size: 9px; }.settings-badge { display: inline-flex; align-items: center; min-height: 19px; padding: 0 8px; border: 1px solid var(--admin-line-strong); border-radius: 99px; color: var(--admin-muted); background: #f6f7f9; font-size: 9px; font-weight: 680; }.settings-badge-ok { border-color: #cfe5da; color: var(--admin-green); background: #edf7f2; }.settings-quota { padding: 2px 19px 18px; }.settings-quota-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 9px; color: var(--admin-muted); font-size: 10px; }.settings-quota-top strong { color: var(--admin-text); font-family: var(--font-geist-mono), monospace; font-size: 13px; font-weight: 620; }.settings-quota small { display: block; margin-top: 8px; color: var(--admin-subtle); font-size: 9px; }.settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 19px; }.settings-footer > div:first-child { display: flex; align-items: center; gap: 11px; }.settings-footer svg { color: var(--admin-orange); }.settings-footer strong, .settings-footer small { display: block; }.settings-footer strong { color: var(--admin-text); font-size: 11px; font-weight: 650; }.settings-footer small { margin-top: 2px; color: var(--admin-subtle); font-size: 9px; }.settings-footer-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
@media (max-width: 880px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 760px) { .settings-rows { padding-inline: 14px; }.settings-quota { padding-inline: 14px; }.settings-footer { align-items: start; flex-direction: column; } }
|
||||||
@media (max-width: 1050px) { .admin-sidebar { width: 210px; flex-basis: 210px; }.admin-sidebar.is-collapsed { width: 64px; flex-basis: 64px; }.admin-collapse-toggle { left: 192px; }.admin-sidebar.is-collapsed ~ .admin-collapse-toggle { left: 50px; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-overview-grid { grid-template-columns: 1fr; }.admin-overview-grid .admin-section-card { margin-bottom: 0; }.admin-topbar { padding-inline: 24px; }.admin-content { padding-inline: 24px; } }
|
@media (max-width: 1050px) { .admin-sidebar { width: 210px; flex-basis: 210px; }.admin-sidebar.is-collapsed { width: 64px; flex-basis: 64px; }.admin-collapse-toggle { left: 192px; }.admin-sidebar.is-collapsed ~ .admin-collapse-toggle { left: 50px; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-overview-grid { grid-template-columns: 1fr; }.admin-overview-grid .admin-section-card { margin-bottom: 0; }.admin-topbar { padding-inline: 24px; }.admin-content { padding-inline: 24px; } }
|
||||||
@media (max-width: 760px) { .admin-sidebar, .admin-collapse-toggle { display: none; }.admin-topbar { height: 53px; padding: 0 15px; }.mobile-menu-button { display: inline-flex; margin-right: 5px; }.admin-topbar-actions { gap: 8px; }.admin-search-trigger { min-width: 31px; width: 31px; justify-content: center; padding: 0; }.admin-search-trigger span, .admin-search-trigger kbd, .admin-topbar-action span, .admin-auth-link { display: none; }.admin-topbar-action { width: 31px; height: 31px; justify-content: center; padding: 0; border: 1px solid var(--admin-line-strong); border-radius: 6px; }.admin-content { padding: 24px 15px 45px; }.admin-page-heading { align-items: start; flex-direction: column; gap: 15px; margin-bottom: 22px; }.admin-page-heading h1 { font-size: 24px; }.admin-page-actions { width: 100%; }.admin-page-actions .ui-button { flex: 1; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.admin-metric-card { min-height: 102px; padding: 13px; }.admin-metric-card strong { margin-top: 13px; font-size: 21px; }.admin-section-card-header { padding-inline: 14px; }.health-list { grid-template-columns: repeat(2, 1fr); padding-inline: 14px; }.quota-summary { padding-inline: 14px; }.admin-table th, .admin-table td { padding-inline: 13px; }.admin-toolbar { flex-wrap: wrap; padding-inline: 13px; }.admin-search-field { min-width: 100%; max-width: none; }.admin-toolbar-note { margin-left: 0; }.admin-form-grid, .release-detail-grid, .json-summary-grid, .settings-placeholder-grid { grid-template-columns: 1fr; }.ui-dialog-content { width: min(100% - 22px, 520px); padding: 20px 17px; }.ui-dialog-content.wide-dialog { width: min(100% - 22px, 680px); }.admin-toast-viewport { right: 11px; bottom: 11px; max-width: calc(100vw - 22px); }.admin-toast { width: 100%; } }
|
@media (max-width: 760px) { .admin-sidebar, .admin-collapse-toggle { display: none; }.admin-topbar { height: 53px; padding: 0 15px; }.mobile-menu-button { display: inline-flex; margin-right: 5px; }.admin-topbar-actions { gap: 8px; }.admin-search-trigger { min-width: 31px; width: 31px; justify-content: center; padding: 0; }.admin-search-trigger span, .admin-search-trigger kbd, .admin-topbar-action span, .admin-auth-link { display: none; }.admin-topbar-action { width: 31px; height: 31px; justify-content: center; padding: 0; border: 1px solid var(--admin-line-strong); border-radius: 6px; }.admin-content { padding: 24px 15px 45px; }.admin-page-heading { align-items: start; flex-direction: column; gap: 15px; margin-bottom: 22px; }.admin-page-heading h1 { font-size: 24px; }.admin-page-actions { width: 100%; }.admin-page-actions .ui-button { flex: 1; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.admin-metric-card { min-height: 102px; padding: 13px; }.admin-metric-card strong { margin-top: 13px; font-size: 21px; }.admin-section-card-header { padding-inline: 14px; }.health-list { grid-template-columns: repeat(2, 1fr); padding-inline: 14px; }.quota-summary { padding-inline: 14px; }.admin-table th, .admin-table td { padding-inline: 13px; }.admin-toolbar { flex-wrap: wrap; padding-inline: 13px; }.admin-search-field { min-width: 100%; max-width: none; }.admin-toolbar-note { margin-left: 0; }.admin-form-grid, .release-detail-grid, .json-summary-grid, .settings-placeholder-grid { grid-template-columns: 1fr; }.ui-dialog-content { width: min(100% - 22px, 520px); padding: 20px 17px; }.ui-dialog-content.wide-dialog { width: min(100% - 22px, 680px); }.admin-toast-viewport { right: 11px; bottom: 11px; max-width: calc(100vw - 22px); }.admin-toast { width: 100%; } }
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export type Notice = {
|
|||||||
|
|
||||||
export type Visibility = SourceRecord["visibility"];
|
export type Visibility = SourceRecord["visibility"];
|
||||||
|
|
||||||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
|
// Single-user deployment: hard-code the public API origin. The API is served
|
||||||
|
// at api.altdock.app.amania.jp; there is no per-environment fallback anymore.
|
||||||
|
export const API_BASE = "https://api.altdock.app.amania.jp";
|
||||||
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
|
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
|
||||||
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
|
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
|
||||||
|
|
||||||
@@ -39,7 +41,9 @@ export async function api<T>(path: string, init: RequestInit = {}) {
|
|||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
headers.set("accept", "application/json");
|
headers.set("accept", "application/json");
|
||||||
if (init.body) headers.set("content-type", "application/json");
|
if (init.body) headers.set("content-type", "application/json");
|
||||||
|
if (AUTH_MODE === "demo") {
|
||||||
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
|
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
|
||||||
|
}
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
|
||||||
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
|
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
|
||||||
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
|
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
|
||||||
@@ -102,4 +106,3 @@ export function flattenApps(dashboard: DashboardData) {
|
|||||||
export function flattenReleases(dashboard: DashboardData) {
|
export function flattenReleases(dashboard: DashboardData) {
|
||||||
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
|
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -1,10 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
|
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
|
||||||
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
|
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// SSOモードでログイン済みなら、ランディングを挟まずダッシュボードへ向かう。
|
||||||
|
useEffect(() => {
|
||||||
|
if (AUTH_MODE !== "oidc") return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
|
||||||
|
.then((response) => { if (response.ok && !cancelled) router.replace("/dashboard"); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return <main className="landing">
|
return <main className="landing">
|
||||||
<header className="landing-header">
|
<header className="landing-header">
|
||||||
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
|
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
|
||||||
@@ -24,7 +38,7 @@ export default function Home() {
|
|||||||
<Link className="ui-button ui-button-primary" href="/dashboard">ダッシュボードを開く <ArrowRight size={15} /></Link>
|
<Link className="ui-button ui-button-primary" href="/dashboard">ダッシュボードを開く <ArrowRight size={15} /></Link>
|
||||||
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">配布要件を見る</a>
|
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">配布要件を見る</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="landing-hero-meta"><Gauge size={14} />Demo mode で起動中 · API <code>{API_BASE}</code></div>
|
{AUTH_MODE === "demo" && <div className="landing-hero-meta"><Gauge size={14} />Demo mode で起動中 · API <code>{API_BASE}</code></div>}
|
||||||
</div>
|
</div>
|
||||||
<aside className="landing-hero-card" aria-hidden="true">
|
<aside className="landing-hero-card" aria-hidden="true">
|
||||||
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
|
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ type SourceDocument = {
|
|||||||
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
|
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
|
// Single-user deployment: hard-code the public API origin.
|
||||||
|
const API_BASE = "https://api.altdock.app.amania.jp";
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
function formatBytes(value: number) {
|
||||||
if (!value) return "0 B";
|
if (!value) return "0 B";
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ export async function registerAuthRoutes(app: FastifyInstance, appConfig: AppCon
|
|||||||
reply.clearCookie(stateCookie, { path: "/auth" });
|
reply.clearCookie(stateCookie, { path: "/auth" });
|
||||||
reply.clearCookie(verifierCookie, { path: "/auth" });
|
reply.clearCookie(verifierCookie, { path: "/auth" });
|
||||||
reply.clearCookie(nonceCookie, { path: "/auth" });
|
reply.clearCookie(nonceCookie, { path: "/auth" });
|
||||||
return reply.redirect(appConfig.WEB_ORIGIN);
|
// SSO成功後はランディングではなく管理ダッシュボードへ直接向かう。
|
||||||
|
return reply.redirect(`${appConfig.WEB_ORIGIN.replace(/\/$/, "")}/dashboard`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/auth/logout", async (_request, reply) => {
|
app.get("/auth/logout", async (_request, reply) => {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const schema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
HOST: z.string().default("0.0.0.0"),
|
HOST: z.string().default("0.0.0.0"),
|
||||||
PORT: numberFromEnv.default(4000),
|
PORT: numberFromEnv.default(4000),
|
||||||
PUBLIC_BASE_URL: z.string().url().default("http://localhost:4000"),
|
PUBLIC_BASE_URL: z.string().url().default("https://api.altdock.app.amania.jp"),
|
||||||
WEB_ORIGIN: z.string().url().default("http://localhost:3000"),
|
WEB_ORIGIN: z.string().url().default("https://altdock.app.amania.jp"),
|
||||||
DATABASE_URL: z.string().optional(),
|
DATABASE_URL: z.string().optional(),
|
||||||
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
|
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
|
||||||
LOCAL_STORAGE_DIR: z.string().default(".data/storage"),
|
LOCAL_STORAGE_DIR: z.string().default(".data/storage"),
|
||||||
@@ -30,7 +30,7 @@ const schema = z.object({
|
|||||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
OIDC_REDIRECT_URI: z.string().url().optional(),
|
OIDC_REDIRECT_URI: z.string().url().default("https://api.altdock.app.amania.jp/auth/callback"),
|
||||||
MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024),
|
MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024),
|
||||||
MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048),
|
MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048),
|
||||||
MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024),
|
MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import pkg from "../../../package.json" with { type: "json" };
|
||||||
import { buildSourceDocument } from "../../../packages/core/src/index";
|
import { buildSourceDocument } from "../../../packages/core/src/index";
|
||||||
import type { Identity } from "../../../packages/core/src/index";
|
import type { Identity } from "../../../packages/core/src/index";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
@@ -78,13 +79,20 @@ export async function createServer() {
|
|||||||
// them in memory, so a 5 GiB ADP doesn't exhaust the process.
|
// them in memory, so a 5 GiB ADP doesn't exhaust the process.
|
||||||
app.addContentTypeParser(["application/zip", "application/octet-stream"], (_request, payload, done) => done(null, payload));
|
app.addContentTypeParser(["application/zip", "application/octet-stream"], (_request, payload, done) => done(null, payload));
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
// The control plane (web :3000) and API (:4000) are cross-origin, so allow
|
// The control plane (web :3001) and API (:4000) are cross-origin, so allow
|
||||||
// the write methods the dashboard uses; @fastify/cors otherwise defaults to
|
// the write methods the dashboard uses; @fastify/cors otherwise defaults to
|
||||||
// GET/HEAD/POST and blocks PATCH/PUT in the browser.
|
// GET/HEAD/POST and blocks PATCH/PUT in the browser.
|
||||||
await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] });
|
await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] });
|
||||||
await registerAuthRoutes(app, appConfig);
|
await registerAuthRoutes(app, appConfig);
|
||||||
|
|
||||||
app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE }));
|
app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE }));
|
||||||
|
app.get("/version", async () => ({
|
||||||
|
name: "altdock-api",
|
||||||
|
version: pkg.version,
|
||||||
|
commit: process.env.APP_COMMIT || process.env.SOURCE_COMMIT || "unknown",
|
||||||
|
authMode: appConfig.AUTH_MODE,
|
||||||
|
node: process.version,
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/api/v1/me", async (request, reply) => {
|
app.get("/api/v1/me", async (request, reply) => {
|
||||||
const identity = await requireIdentity(request, reply, appConfig);
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Link from "next/link";
|
|||||||
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
|
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
|
||||||
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
|
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
|
||||||
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
|
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
|
||||||
|
import type { Identity } from "@/packages/core/src/index";
|
||||||
import { useDashboard } from "./dashboard-provider";
|
import { useDashboard } from "./dashboard-provider";
|
||||||
|
|
||||||
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
|
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
|
||||||
@@ -34,6 +35,7 @@ function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavi
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { dashboard } = useDashboard();
|
const { dashboard } = useDashboard();
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
|
||||||
const renderItem = (item: NavItem) => {
|
const renderItem = (item: NavItem) => {
|
||||||
const external = item.href.startsWith("http");
|
const external = item.href.startsWith("http");
|
||||||
@@ -55,10 +57,24 @@ function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavi
|
|||||||
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
|
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
|
||||||
<div className="admin-sidebar-spacer" />
|
<div className="admin-sidebar-spacer" />
|
||||||
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span>保存容量</span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
|
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span>保存容量</span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
|
||||||
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">D</span><span><strong>Demo Developer</strong><small>demo@altdock.local</small></span><span className="admin-user-menu">···</span></div>}
|
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">{(identity?.displayName || "D").slice(0, 1).toUpperCase()}</span><span><strong>{identity?.displayName || "Demo Developer"}</strong><small>{identity?.email || "demo@altdock.local"}</small></span><span className="admin-user-menu">···</span></div>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useIdentity() {
|
||||||
|
const [identity, setIdentity] = useState<Identity | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (AUTH_MODE !== "oidc") return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((body) => { if (!cancelled && body?.identity) setIdentity(body.identity as Identity); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
return { identity };
|
||||||
|
}
|
||||||
|
|
||||||
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
|
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
|
||||||
@@ -70,7 +86,8 @@ export function AdminShell({ children }: PropsWithChildren) {
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [commandOpen, setCommandOpen] = useState(false);
|
const [commandOpen, setCommandOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { notice, dismissNotice, dashboard } = useDashboard();
|
const { notice, dismissNotice } = useDashboard();
|
||||||
|
const { identity } = useIdentity();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleShortcut = (event: KeyboardEvent) => {
|
const handleShortcut = (event: KeyboardEvent) => {
|
||||||
@@ -87,7 +104,7 @@ export function AdminShell({ children }: PropsWithChildren) {
|
|||||||
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
|
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
|
||||||
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only">管理画面ナビゲーション</DialogTitle><DialogDescription className="sr-only">管理画面の移動先を選択します。</DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
|
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only">管理画面ナビゲーション</DialogTitle><DialogDescription className="sr-only">管理画面の移動先を選択します。</DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
|
||||||
<section className="admin-main-column">
|
<section className="admin-main-column">
|
||||||
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span>検索</span><kbd>⌘ K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} />新しいリリース</Link>{AUTH_MODE === "oidc" ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
|
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span>検索</span><kbd>⌘ K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} />新しいリリース</Link>{AUTH_MODE === "oidc" ? (!identity ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : null) : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
|
||||||
<div className="admin-content">{children}</div>
|
<div className="admin-content">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
|
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Activity, ArrowUpRight, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
|
import { Activity, ArrowUpRight, Boxes, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
|
||||||
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
|
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
|
||||||
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
|
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
|
||||||
import { useDashboard } from "./dashboard-provider";
|
import { useDashboard } from "./dashboard-provider";
|
||||||
|
|||||||
+14
-7
@@ -19,6 +19,9 @@ x-altdock-app: &altdock-app
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# Coolify provides SOURCE_COMMIT at build time; falls back to unknown.
|
||||||
|
APP_COMMIT: ${SOURCE_COMMIT:-unknown}
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -76,8 +79,11 @@ services:
|
|||||||
S3_FORCE_PATH_STYLE: "true"
|
S3_FORCE_PATH_STYLE: "true"
|
||||||
AUTH_MODE: ${AUTH_MODE:-demo}
|
AUTH_MODE: ${AUTH_MODE:-demo}
|
||||||
PROCESS_INLINE: "false"
|
PROCESS_INLINE: "false"
|
||||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:4000}
|
# Public origin of the API; used for source.json downloadURLs and
|
||||||
WEB_ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
|
# upload plan URLs. Override for local development.
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://api.altdock.app.amania.jp}
|
||||||
|
WEB_ORIGIN: ${WEB_ORIGIN:-https://altdock.app.amania.jp}
|
||||||
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-https://api.altdock.app.amania.jp/auth/callback}
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -108,14 +114,15 @@ services:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
<<: *altdock-app
|
<<: *altdock-app
|
||||||
# vinext uses --hostname (not vite's --host) to bind the dev server.
|
# vinext uses --hostname/--port (not vite's --host) to bind the dev server.
|
||||||
command: npm run dev -- --hostname 0.0.0.0
|
command: npm run dev -- --hostname 0.0.0.0 --port 3001
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
# API origin is hard-coded in app/lib/dashboard.ts (single-user deploy).
|
||||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:4000}
|
|
||||||
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE:-demo}
|
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE:-demo}
|
||||||
|
# Hosts allowed to reach the dev server (comma-separated, or * for any).
|
||||||
|
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-altdock.app.amania.jp}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3001:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
+22
-1
@@ -11,6 +11,19 @@ const { d1, r2 } = hostingConfig;
|
|||||||
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||||
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||||
|
|
||||||
|
// Vite 8 blocks unknown Host headers by default. In dev (incl. Coolify
|
||||||
|
// proxying a public domain to the dev server) the public hosts must be
|
||||||
|
// allow-listed. Accepts a comma-separated list, or "*"/"true" for any host.
|
||||||
|
function parseAllowedHosts(raw: string | undefined): boolean | string[] | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value || value === "*" || value === "true") return true;
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((host) => host.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
const localBindingConfig = {
|
const localBindingConfig = {
|
||||||
main: "./worker/index.ts",
|
main: "./worker/index.ts",
|
||||||
compatibility_flags: ["nodejs_compat"],
|
compatibility_flags: ["nodejs_compat"],
|
||||||
@@ -43,9 +56,17 @@ export default defineConfig(async () => {
|
|||||||
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||||
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||||
|
|
||||||
|
const allowedHosts = parseAllowedHosts(process.env.VITE_ALLOWED_HOSTS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server: isCodexSeatbeltSandbox
|
server:
|
||||||
|
isCodexSeatbeltSandbox || allowedHosts
|
||||||
|
? {
|
||||||
|
...(isCodexSeatbeltSandbox
|
||||||
? { watch: { useFsEvents: false, usePolling: true } }
|
? { watch: { useFsEvents: false, usePolling: true } }
|
||||||
|
: {}),
|
||||||
|
...(allowedHosts ? { allowedHosts } : {}),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
plugins: [
|
plugins: [
|
||||||
vinext(),
|
vinext(),
|
||||||
|
|||||||
Reference in New Issue
Block a user